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 +21 -0
- ohapi-0.1.0/PKG-INFO +124 -0
- ohapi-0.1.0/README.md +106 -0
- ohapi-0.1.0/ohapi/__init__.py +31 -0
- ohapi-0.1.0/ohapi/_client.py +259 -0
- ohapi-0.1.0/ohapi/_types.py +213 -0
- ohapi-0.1.0/ohapi/_validate.py +74 -0
- ohapi-0.1.0/ohapi/client.py +287 -0
- ohapi-0.1.0/ohapi/py.typed +0 -0
- ohapi-0.1.0/ohapi.egg-info/PKG-INFO +124 -0
- ohapi-0.1.0/ohapi.egg-info/SOURCES.txt +15 -0
- ohapi-0.1.0/ohapi.egg-info/dependency_links.txt +1 -0
- ohapi-0.1.0/ohapi.egg-info/top_level.txt +1 -0
- ohapi-0.1.0/pyproject.toml +31 -0
- ohapi-0.1.0/setup.cfg +4 -0
- ohapi-0.1.0/tests/test_integration.py +134 -0
- ohapi-0.1.0/tests/test_unit.py +712 -0
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
|
+
)
|