ohapi 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.
ohapi-0.1.0/LICENSE ADDED
@@ -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.
ohapi-0.1.0/PKG-INFO ADDED
@@ -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.
ohapi-0.1.0/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # ohapi
2
+
3
+ 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.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pip install ohapi
9
+ ```
10
+
11
+ The package is not on PyPI yet.
12
+
13
+ ## Example
14
+
15
+ ```python
16
+ import os
17
+
18
+ from ohapi import Ohapi, OhapiError, Pending
19
+
20
+ ohapi = Ohapi(os.environ["OHAPI_TOKEN"])
21
+
22
+ result = ohapi.secrets.set(project_id, "production", "API_URL", "https://api.example.com", confirm_production=True)
23
+
24
+ if isinstance(result, Pending):
25
+ # Dual control holds the change. Nothing is written yet.
26
+ try:
27
+ ohapi.change_requests.wait(result.change_request_id)
28
+ except OhapiError as error:
29
+ if error.code != "rejected":
30
+ raise
31
+ print("A reviewer refused the change:", error.change_request.status)
32
+ else:
33
+ print("Wrote version", result.version)
34
+ ```
35
+
36
+ ## Methods
37
+
38
+ | Method | Endpoint | Audit row |
39
+ | --- | --- | --- |
40
+ | `me()` | `GET /api/v1/me` | No |
41
+ | `projects.list()`, `projects.get(id)` | `GET /api/v1/projects`, `…/{id}` | No |
42
+ | `environments.list()`, `.iterate()`, `.get()` | `GET …/environments` | No |
43
+ | `secrets.list()`, `.iterate()`, `.get()` | `GET …/secrets` | **No.** Metadata only. |
44
+ | `secrets.value()` | `GET …/secrets/{key}/value` | **Yes.** One `READ` row for each call. |
45
+ | `secrets.pull()` | `GET /api/v1/secrets` | **Yes.** One `READ` row for the environment. Expired keys are omitted. |
46
+ | `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. |
47
+ | `change_requests.get()`, `.wait()` | `GET /api/v1/change-requests/{id}` | No |
48
+
49
+ `list()` takes `page` and `page_size`. `iterate()` takes `page_size`. The server accepts page numbers up to 10000 and page sizes up to 500.
50
+
51
+ The client is synchronous. There is no `asyncio` client.
52
+
53
+ ## Pending writes
54
+
55
+ 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`.
56
+
57
+ A write returns one of these types:
58
+
59
+ | Call | Result |
60
+ | --- | --- |
61
+ | `set()`, `create()` | `Written(key, version, created, id)` |
62
+ | `patch()` | `Patched(key)` |
63
+ | `delete()` | `Deleted(key)` |
64
+ | Any of them, when dual control holds the change | `Pending(change_request_id, key)` |
65
+
66
+ `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`.
67
+
68
+ ## Omitted and `None`
69
+
70
+ `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.
71
+
72
+ ## Errors
73
+
74
+ 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`.
75
+
76
+ | Code | Cause |
77
+ | --- | --- |
78
+ | `invalid` | Bad input. The SDK refused it before any request, or the server answered `400`. |
79
+ | `unauthenticated` | `401`. The key is missing, wrong, or expired. |
80
+ | `forbidden` | `403`. The key has a read-only role, the route is for admins only, or the key scope does not cover the target. |
81
+ | `not_found` | `404`. The thing does not exist, or the key cannot reach it. |
82
+ | `conflict` | `409`. `create()` found the key already there. |
83
+ | `unprocessable` | `422`. The server refused the value. |
84
+ | `rate_limited` | `429`. `retry_after_seconds` holds the wait. |
85
+ | `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. |
86
+ | `network` | No connection. |
87
+ | `timeout` | The whole request, headers and body included, did not finish within `timeout_s` (30 seconds by default), or `wait()` ended. |
88
+ | `rejected` | `wait()` found a rejected or cancelled change request. |
89
+
90
+ The SDK does not retry. You decide the retry policy. The SDK does not log.
91
+
92
+ ## The key
93
+
94
+ 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.
95
+
96
+ ## Not included
97
+
98
+ 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.
99
+
100
+ ## Tests
101
+
102
+ ```sh
103
+ sdk-python/scripts/test.sh
104
+ ```
105
+
106
+ The script runs the unit tests in Docker. The tests need no server.
@@ -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
+ ]
@@ -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
+ )